我是後端的初學者,所以通常會先寫測試讓自己比較熟悉,另外,在開發過程中立即運行測試,也就是完成一個邏輯先跑測試,這樣可以及早發現和修復錯誤,避免在後期修復時增加的工作量和複雜性。
以下主要是依照第 23 天:代碼質量與測試 - 寫測試的流程除了基本規劃以外,進入開發前先寫測試。
今天和明天的任務主要是開後台 api ,主要運用 CRUD 操作來實現後台任務管理應用基本功能
我自己在前端的時候評估任務時程都會拆小的任務包,內容就是確認重要需求以及任務可能會使用的技能,最重要的是...會不會有機會撞牆!?
站在後端角色做一個後台任務管理應用,用到基本的 CRUD ,可以參考前輩的商品後台,另外,站在使用者體驗的角度觀察,如果我是一個後台管理者,我會需要哪些功能?
需要開的 api 明細
| no | 項目 | 方法 | 說明 | 請求驗證 | 
|---|---|---|---|---|
| 1 | 任務列表 | GET | 顯示所有任務 | 前端不用帶參數 | 
| 2 | 創建任務 | POST | 新增一個任務 | 前端依照需求帶參數存入資料庫 | 
| 3 | 更新任務 | PUT | 修改現有的任務 | 前端路由中帶 task_id | 
| 4 | 更新任務 | PETCH | 修改現有任務的一小部分 | 前端路由中帶 task_id 和布林值 | 
| 5 | 刪除任務 | DELECT | 刪除一個任務 | 前端路由中帶 task_id | 
過去前端的經驗告訴我:身為後端要做好溝通的橋樑,開發前除了想一下表格架構以外,也可以找雙方討論交流回傳格式,避免前端收到太大的驚喜!

圖片來源:(專案倒數1天) 你需要的API我都做完了唷!加油!
第一步會先做建立路由和閉包的方法,主要是要確保給前端的架構和 HTTP 狀態必須符合當初的溝通結果,因為這裡開始就是要回傳給前端的關鍵路徑,其他進入 service 和 repository 的部分就是後端自己的事情,這裡最重要的就是回傳是要正確的,其他後端自己處理即可!
<?php
use App\Http\Controllers\TaskController;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
Route::prefix('tasks')->name('tasks.')->group(function () {
    Route::post('/', [TaskController::class, 'storeTask'])->name('create');
    Route::put('/{task}', [TaskController::class, 'updateTask'])->name('update');
    Route::patch('/{task}/{completed}', [TaskController::class, 'changeTaskComplete'])->name('change.complete');
    Route::delete('/{task}', [TaskController::class, 'deleteTask'])->name('delete');
    Route::get('/', [TaskController::class, 'showTasks'])->name('show');
});
不過,寫測試之前先做工廠建立,因為要做比對用的!
建立工廠
指令 php artisan make:factory TaskFactory --model=Task,然後在 database/factories/TaskFactory.php 中定義工廠的欄位。
<?php
namespace Database\Factories;
use App\Models\Task;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
 * 任務工廠
 * 
 * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Task>
 */
class TaskFactory extends Factory
{
    protected $model = Task::class;
    /**
     * 定義模型的預設狀態。
     * Define the model's default state.
     *
     * @return array<string, mixed>
     */
    public function definition()
    {
        return [
            'title' => $this->faker->sentence,
            'description' => $this->faker->paragraph,
            'completed' => $this->faker->boolean,
            'created_at' => now(),
            'updated_at' => now(),
        ];
    }
}
寫測試 step by step
首先這裡知道要驗的是 url & controller,所以依照第 23 天:代碼質量與測試 - 寫測試的流程執行。

<?php
namespace Tests\Feature;
use App\Models\Task;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/**
 * 測試後台任務管理應用控制器
 * 
 */
class TaskControllerTest extends TestCase
{
    use RefreshDatabase;
    /**
     * 測試取得所有任務明細
     * 
     * @return void
     */
    public function testShowTasks(): void
    {
        // 工廠建資料
        Task::factory()->count(count: 3)->create();
        // 跑結果 & 比對
        $this->get(uri: '/api/tasks')
            ->assertOk()
            ->assertJsonCount(count: 3);
    }
    /**
     * 測試新增任務
     * 
     * @return void
     */
    public function testStoreTask(): void
    {
        $data = [
            "title" => "new_task",
            "description" => "test",
            "completed" => true,
        ];
        $this->post(uri: '/api/tasks', data: $data)
            ->assertStatus(status: 201)
            ->assertJson(value: $data);
        $this->assertDatabaseHas(table: 'tasks', data: $data);
    }
    /**
     * 測試編輯任務
     * @return void
     */
    public function testUpdateTask(): void
    {
        $task = Task::factory()->create();
        $data = [
            'title' => 'Updated Task',
            "description" => "test",
            "completed" => true,
        ];
        $this->put(uri: "/api/tasks/{$task->id}", data: $data)
            ->assertOk()
            ->assertJson(value: $data);
        $this->assertDatabaseHas(table: 'tasks', data: $data);
    }
    /**
     * 測試改變任務的完成欄位
     *
     * @return void
     */
    public function testChangeTaskComplete(): void
    {
        $task = Task::factory()->create(attributes: ['completed' => true]);
        $this->patch(uri: "/api/tasks/{$task->id}/{$task->completed}")
            ->assertOk();
    }
    /**
     * 測試刪除任務
     * 
     * @return void
     */
    public function testDeleteTask(): void
    {
        $task = Task::factory()->create();
        $this->delete("/api/tasks/{$task->id}")
            ->assertOk();
    }
}
單元測試主要分成 service、repository、transformer,甚至是 request 也可以,這邊主要先以 service、repository 為主!
進入單元測試後,一定會很常遇到過不了的問題,因為邏輯只寫了一半,所以難免會需要回頭改,但是至少註解寫上後知道要多注意哪些細節,避免壓線的時候才發現 bug 或是需求沒有完善!開始吧!
service
指令 php artisan make:test TaskServiceTest --unit,預期都是過水,大部分都直接傳入 repository 做 CRUD,所以這裡做一個測試建立任務的邏輯處理
<?php
namespace Tests\Unit;
use App\Models\Task;
use App\Repositories\TaskRepository;
use App\Services\TaskService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Mockery;
use Mockery\LegacyMockInterface;
use Mockery\MockInterface;
use Tests\TestCase;
class TaskServiceTest extends TestCase
{
    use RefreshDatabase;
    protected LegacyMockInterface|MockInterface $mock_repository;
    protected TaskService $service;
    protected function setUp(): void
    {
        parent::setUp();
        $this->mock_repository = Mockery::mock(TaskRepository::class);
        $this->service = new TaskService($this->mock_repository);
    }
    /**
     * 測試新增任務的邏輯處理
     * 
     * @return void
     */
    public function testStoreTask(): void
    {
        // 設定測試資料
        $data = [
            'title' => 'New Task',
            'description' => 'Task description',
            'completed' => false,
        ];
        // 用工廠建立預期結果
        $expected = Task::factory()->make($data);
        // 用 mock 開始模擬,調用方法並且執行一次並且回傳前面建立的預設值
        $this->mock_repository->shouldReceive('storeTask')
            ->once()
            ->andReturn($expected);
        // 調用被測試的方法
        $activity = $this->service->storeTask($data);
        // 斷言比對
        $this->assertEquals($expected, $activity);
    }
}
repository
指令 php artisan make:test TaskRepositoryTest --unit
<?php
namespace Tests\Unit;
use App\Models\Task;
use App\Repositories\TaskRepository;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class TaskRepositoryTest extends TestCase
{
    use RefreshDatabase;
    protected TaskRepository $repository;
    protected function setUp(): void
    {
        parent::setUp();
        $this->repository = app()->make(TaskRepository::class);
    }
    /**
     * 測試取得所有任務明細的資料處理
     * 
     * @return void
     */
    public function testShowTasks(): void
    {
        Task::factory()->count(3)->create();
        $this->assertDatabaseCount(Task::class, 3);
    }
    /**
     * 測試新增任務的資料處理
     * 
     * @return void
     */
    public function testStoreTask(): void
    {
        // 預設
        $data = [
            'title' => 'New Task',
            'description' => 'Task description',
            'completed' => false,
        ];
        // 模擬跑出來的真值
        $task = $this->repository->storeTask($data);
        // 斷言
        $this->assertDatabaseHas(Task::class, $data);
        $this->assertEquals($data['title'], $task->title);
    }
    /**
     * 測試編輯任務的資料處理
     * 
     * @return void
     */
    public function testUpdateTask(): void
    {
        // 預設
        $task = Task::factory()->create();
        $data = [
            'title' => 'Updated Task',
            'description' => 'Updated description',
            'completed' => true,
        ];
        // 模擬跑出來的真值
        $updatedTask = $this->repository->updateTask($task, $data);
        // 斷言
        $this->assertEquals(expected: $data['title'], actual: $updatedTask->title);
        $this->assertDatabaseHas(Task::class, $data);
    }
    /**
     * 測試改變任務的完成欄位資料處理
     * 
     * @return void
     */
    public function testChangeTaskComplete(): void
    {
        // 預設
        $task = Task::factory()->create(['completed' => false]);
        // 模擬跑出來的真值
        $this->repository->changeTaskComplete($task, true);
        // 斷言
        $this->assertDatabaseHas(table: Task::class, data: ['id' => $task->id, 'completed' => true]);
        $this->assertTrue($task->fresh()->completed);
    }
    /**
     * 測試刪除任務的資料處理
     * 
     * @return void
     */
    public function testDeleteTask(): void
    {
        // 預設
        $task = Task::factory()->create();
        // 模擬跑出來的真值
        $this->repository->deleteTask($task);
        // 斷言
        $this->assertDatabaseMissing(Task::class, ['id' => $task->id]);
    }
}